feat: add v2 codemod draft#1950
Conversation
|
|
@claude review |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
|
@claude review |
|
@claude review |
|
@claude review |
|
Todo:
|
|
@claude review |
|
@claude review |
|
@claude review |
… into feature/v2-codemode-draft
|
@claude review |
|
@claude review |
|
@claude review |
|
@claude review |
|
@claude review |
| const parent = ref.getParent(); | ||
| if (!parent) return false; | ||
| if (Node.isTypeQuery(parent)) return true; | ||
| // typeof inside a type argument like z.infer<typeof X> | ||
| if (parent.getKind() === SyntaxKind.TypeOfExpression) return true; | ||
| return false; | ||
| } | ||
|
|
There was a problem hiding this comment.
🟡 The SyntaxKind.TypeOfExpression check on line 213 doesn't match what its comment describes — type-level typeof X (as in z.infer<typeof X> or type T = typeof X) is SyntaxKind.TypeQuery, which line 211 already covers; TypeOfExpression is the value-level runtime unary operator (const k = typeof x, evaluates to a string). So line 213 is dead for its stated purpose, and when it does fire (e.g. const kind = typeof ToolSchema) it emits a misleading "Replace z.infer<typeof ToolSchema> …" diagnostic for code that has no z.infer. Just delete lines 212-213 — Node.isTypeQuery is sufficient for all type-position typeof.
Extended reasoning...
What the issue is
isTypeofInTypePosition (specSchemaAccess.ts:208-215) checks two parent kinds:
if (Node.isTypeQuery(parent)) return true;
// typeof inside a type argument like z.infer<typeof X>
if (parent.getKind() === SyntaxKind.TypeOfExpression) return true;In the TypeScript AST these are two distinct node kinds:
TypeQuery(SyntaxKind.TypeQuery) is type-leveltypeof X— used intype T = typeof X,z.infer<typeof X>,let a: typeof X. This is what line 211 matches, and it already covers everyz.infer<typeof X>case.TypeOfExpression(SyntaxKind.TypeOfExpression) is the value-level runtime unary operator —const k = typeof x,if (typeof x === 'object')— which evaluates to a string at runtime. This is what line 213 matches.
So the comment on line 212 ("typeof inside a type argument like z.infer") is factually incorrect about what TypeOfExpression matches — that case is TypeQuery, already handled one line up. Two verifiers confirmed this empirically against ts-morph 28.0.0: for z.infer<typeof Foo> and type T = typeof Foo the parent of Foo is TypeQuery (isTypeQuery → true, TypeOfExpression → false); for const kind = typeof Foo the parent is TypeOfExpression.
What it actually matches
Line 213 only fires for runtime typeof <schema>, e.g.:
import { ToolSchema } from '@modelcontextprotocol/server';
const kind = typeof ToolSchema; // evaluates to "object" at runtimeWhen it fires, handleReference emits the diagnostic at line 73-78: "Replace z.infer<typeof ToolSchema> with the Tool type (already exported from the same v2 package)." — which is misleading: there's no z.infer, no type inference, and the user's intent (runtime typeof, always evaluates to "object") has nothing to do with type-position usage. The function name isTypeofInTypePosition also lies for this branch — TypeOfExpression is by definition not in type position.
Why the existing test doesn't exercise line 213
The test 'emits diagnostic for typeof in type position' (specSchemaAccess.test.ts:104-113) uses:
type Result = typeof CallToolResultSchema;That's a TypeQuery — matched by line 211, not line 213. So the test passes regardless of whether line 213 exists, and provides no coverage for the TypeOfExpression branch.
Step-by-step proof
Input:
import { ToolSchema } from '@modelcontextprotocol/server';
const kind = typeof ToolSchema;collectSpecSchemaImports→{ ToolSchema → ToolSchema };typeName = 'Tool'.findNonImportReferencesreturns theToolSchemaidentifier insidetypeof ToolSchema.parentis aTypeOfExpressionnode (the runtime unary operator).handleReference→isTypeofInTypePosition(ref): line 211Node.isTypeQuery(parent)→ false (it'sTypeOfExpression, notTypeQuery); line 213parent.getKind() === SyntaxKind.TypeOfExpression→ true. Returnstrue.handleReferenceline 72-79 pusheswarning(..., "Replacez.inferwith theTooltype ...")and returnsfalse.- The body reference is left unchanged, so
removeUnusedImportat line 30 seesreferenceCount === 1and theToolSchemaimport survives → eventually TS2305 (since v2 doesn't export individual*Schemanames), but with a diagnostic that points the user atz.inferinstead of the actual problem.
Impact
Diagnostic-quality / dead-code only — nit severity. Runtime typeof <ZodSchemaConstant> is essentially never written in real code (it always evaluates to "object", so it's a useless expression). The source is left unchanged (return false → safe, no corruption), and the only harm when triggered is a confusing diagnostic message. But the comment on line 212 is demonstrably wrong about TypeScript's AST, the function name lies for this branch, and the check is at-best-redundant for its stated purpose. This is the same incorrect-comment / dead-code cleanup bar already applied throughout this PR's review (resolved comments 3133089882, 3143732196).
Fix
Delete lines 212-213 — Node.isTypeQuery(parent) on line 211 already covers z.infer<typeof X>, type T = typeof X, and every other type-position typeof:
function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean {
const parent = ref.getParent();
if (!parent) return false;
return Node.isTypeQuery(parent);
}(If you actually want to handle runtime typeof XSchema — which seems unnecessary given how contrived it is — it should fall through to the value-position branch at line 168 and become typeof specTypeSchemas.Tool, not get the z.infer diagnostic.)
| if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === ref) { | ||
| return false; | ||
| } | ||
|
|
||
| if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref) { | ||
| return false; | ||
| } | ||
|
|
||
| // Value position: replace identifier with specTypeSchemas.X | ||
| const line = ref.getStartLineNumber(); | ||
| ref.replaceWithText(`specTypeSchemas.${typeName}`); | ||
| ensureImport(sourceFile, 'specTypeSchemas'); |
There was a problem hiding this comment.
🟡 One more parent-kind gap in the same family as the just-added guards at lines 159/163: the PropertyAccessExpression check at line 122 only matches when ref is the object (parent.getExpression() === ref), so when a spec-schema name appears as the name node — obj.ToolSchema — it falls through to line 169 and emits obj.specTypeSchemas.Tool (valid syntax, so no ts-morph throw — silent corruption with a misleading 'Replaced ToolSchema…' diagnostic). renameAllReferences already has exactly this guard at astUtils.ts:17 (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node); add the same here returning false, since a property name on an arbitrary object is not a reference to the imported schema.
Extended reasoning...
What the bug is
handleReference() now guards ExportSpecifier (line 134), ShorthandPropertyAssignment (line 145), PropertyAssignment name-node (line 159), and BindingElement property-name (line 163) — the latter two added in the most-recent commit per comment 3193275781. But the PropertyAccessExpression check at line 122 only covers the case where ref is the object of the access (parent.getExpression() === ref, i.e. ToolSchema.something). There is no corresponding guard for when ref is the name node (parent.getNameNode() === ref, i.e. obj.ToolSchema). That case falls through every pattern check and lands on the value-position rewrite at line 169.
Code path that triggers it
findNonImportReferences() (lines 51-60) collects every identifier matching localName whose parent is not an ImportSpecifier. For obj.ToolSchema, the ToolSchema identifier's parent is a PropertyAccessExpression — not filtered — so it's pushed into refs and reaches handleReference().
Why existing code doesn't prevent it
Walking the guard chain for a ref whose parent is a PropertyAccessExpression with parent.getNameNode() === ref:
isTypeofInTypePosition— parent is notTypeQuery/TypeOfExpression→ false.isSafeParseSuccessPattern/isSafeParsePattern/isParsePattern— all checkparent.getName() === 'safeParse'/'parse'(which is'ToolSchema'here, not'safeParse') andparent.getExpression() === ref(expression isobj, not ref) → false.- Line 122 —
Node.isPropertyAccessExpression(parent)✓ butparent.getExpression() === refis false (expression is theobjidentifier) → guard skipped. - Lines 134/145/159/163 — parent is not
ExportSpecifier/ShorthandPropertyAssignment/PropertyAssignment/BindingElement→ all skipped. - Line 169 —
ref.replaceWithText('specTypeSchemas.Tool').
This is internally inconsistent: renameAllReferences (astUtils.ts:17) has exactly this guard — Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node → return — and the test 'does not rename property access names that match renamed symbols' (symbolRenames.test.ts) locks it in for that helper. specSchemaAccess is the one transform that re-implements its own parent-kind guard list and missed this case.
Step-by-step proof
Input:
import { ToolSchema } from '@modelcontextprotocol/server';
const x = registry.ToolSchema;collectSpecSchemaImportsfindsToolSchema(inSPEC_SCHEMA_NAMES) →schemaImports = { ToolSchema → ToolSchema },typeName = 'Tool'.findNonImportReferences(sf, 'ToolSchema'): visits theToolSchemaidentifier insideregistry.ToolSchema. Parent isPropertyAccessExpression, notImportSpecifier→ pushed.handleReference: every pattern check above is false; falls through to line 169.ref.replaceWithText('specTypeSchemas.Tool')→ source becomesconst x = registry.specTypeSchemas.Tool;. This is valid TypeScript syntax (chained property access), so ts-morph does not throw — unlike thePropertyAssignment/BindingElementcases that motivated comment 3193275781, there is no runner rollback here.ensureImport(sf, 'specTypeSchemas')addsspecTypeSchemasto the import.- A misleading
'Replaced ToolSchema with specTypeSchemas.Tool…'warning is pushed — the user is told a schema reference was migrated when in fact an unrelated property access on an arbitrary object was corrupted. removeUnusedImport(sf, 'ToolSchema', true)then removes the originalToolSchemaimport specifier (no body references remain).
Output:
import { specTypeSchemas } from '@modelcontextprotocol/server';
const x = registry.specTypeSchemas.Tool;This may still typecheck (if registry is loosely typed) — silent semantic corruption, the worst codemod failure mode.
Impact
Nit severity. The trigger requires importing a spec schema from an MCP package and having an unrelated obj.<SchemaName> property access in the same file — contrived, in the same rarity class as the PropertyAssignment-name/BindingElement-propertyName cases just guarded at lines 159/163. But unlike those (which threw and triggered rollback), this one produces syntactically-valid output, so it's a silent corruption rather than a loud failure. It's a one-line internal-consistency fix in code that has been iteratively hardened across this PR for exactly this parent-kind-guard class (resolved comments 3189940697, 3193275781).
Fix
Add the missing guard alongside lines 159/163, mirroring astUtils.ts:17:
if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref) {
return false;
}(Returning false rather than emitting a diagnostic, since a property name on an arbitrary object is not a reference to the imported schema at all — same rationale as the PropertyAssignment-name guard at line 159.) Or, more durably, factor the key-position skip list out of renameAllReferences into a shared isKeyPositionIdentifier(node) helper and reuse it both here and in findNonImportReferences.
feat: add
@modelcontextprotocol/codemodpackage for automated v1 → v2 migrationAdds a new
@modelcontextprotocol/codemodpackage that automatically migrates MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to the v2 multi-package architecture (@modelcontextprotocol/client,/server,/core,/node,/express). Powered byts-morphfor AST-level precision and shipped as both a CLI (mcp-codemod) and a programmatic API. Also automatically updatespackage.json— removes the v1 SDK dependency and adds the correct v2 packages based on what the code actually imports.Motivation and Context
The v2 SDK introduces a multi-package structure, renamed APIs, restructured context objects, and removed modules (WebSocket transport, server auth, Zod helpers). Manually migrating a codebase is tedious, error-prone, and blocks adoption. This codemod handles the mechanical 80-90% of migration — rewriting imports, renaming symbols, updating method signatures, and mapping context properties — while emitting actionable diagnostics for the remaining cases that require human judgment.
Architecture
Package Structure
High-Level Flow
flowchart TD A["mcp-codemod v1-to-v2 ./src"] --> B[CLI parses args] B --> C[Runner loads migration] C --> D[Analyze project type<br/>from package.json] D --> E[Create ts-morph Project<br/>glob .ts/.tsx/.mts files] E --> F[Filter out node_modules,<br/>dist, .d.ts, .d.mts] F --> G{For each<br/>source file} G --> H[Apply transforms<br/>in order] H --> I[Collect diagnostics,<br/>change counts,<br/>& used v2 packages] I --> G G -->|done| P[Update package.json:<br/>remove v1 SDK,<br/>add detected v2 packages] P --> J{Dry run?} J -->|yes| K[Report changes<br/>without saving] J -->|no| L[Save modified files<br/>& package.json to disk] K --> M[Print summary:<br/>files changed,<br/>package.json changes,<br/>diagnostics] L --> MTransform Pipeline
Transforms run in a strict order per file. Each transform receives the
SourceFileAST, mutates it in place, and returns a change count plus diagnostics. One failing transform does not block the others.flowchart LR subgraph "Phase 1: Foundation" T1["1. importPaths<br/>Rewrite import specifiers<br/>from v1 → v2 packages"] end subgraph "Phase 2: Symbols" T2["2. symbolRenames<br/>McpError → ProtocolError<br/>ErrorCode split, etc."] T3["3. removedApis<br/>Drop Zod helpers,<br/>IsomorphicHeaders"] end subgraph "Phase 3: API Surface" T4["4. mcpServerApi<br/>.tool() → .registerTool()<br/>restructure args"] T5["5. handlerRegistration<br/>Schema → method string"] T6["6. schemaParamRemoval<br/>Remove schema args"] T7["7. expressMiddleware<br/>hostHeaderValidation<br/>signature update"] end subgraph "Phase 4: Context & Tests" T8["8. contextTypes<br/>extra → ctx,<br/>property path remapping"] T9["9. mockPaths<br/>vi.mock / jest.mock<br/>specifier updates"] end T1 --> T2 --> T3 --> T4 --> T5 & T6 & T7 --> T8 --> T9Import Resolution Strategy
Some v1 paths (e.g.,
@modelcontextprotocol/sdk/types.js) are shared code that could belong to either the client or server package. The codemod resolves these contextually:flowchart TD A["v1 import path"] --> B{Path in<br/>IMPORT_MAP?} B -->|no| Z[Leave unchanged] B -->|yes| C{Status?} C -->|removed| D["Remove import +<br/>emit warning diagnostic"] C -->|moved| E{Target is<br/>RESOLVE_BY_CONTEXT?} C -->|renamed| F["Rewrite path +<br/>rename symbols"] E -->|no| G["Rewrite to<br/>fixed target package"] E -->|yes| H{Project type?} H -->|client only| I["→ @modelcontextprotocol/client"] H -->|server only| J["→ @modelcontextprotocol/server"] H -->|both or unknown| K["→ @modelcontextprotocol/server<br/>(safe default)"]Context Property Remapping
The v2 SDK restructures the handler context from a flat object into nested groups. The
contextTypestransform handles this remapping:flowchart LR subgraph "v1 — flat RequestHandlerExtra" E1["extra.signal"] E2["extra.requestId"] E3["extra.authInfo"] E4["extra.sendRequest(...)"] E5["extra.sendNotification(...)"] E6["extra.taskStore"] end subgraph "v2 — nested BaseContext" C1["ctx.mcpReq.signal"] C2["ctx.mcpReq.id"] C3["ctx.http?.authInfo"] C4["ctx.mcpReq.send(...)"] C5["ctx.mcpReq.notify(...)"] C6["ctx.task?.store"] end E1 --> C1 E2 --> C2 E3 --> C3 E4 --> C4 E5 --> C5 E6 --> C6What Each Transform Does
imports@modelcontextprotocol/sdk/...import paths to their v2 package destinations. Merges duplicate imports, separates type-only imports, resolves ambiguous shared paths by project type. Splits imports when symbols from a single v1 module map to different v2 packages (e.g.,StreamableHTTPServerTransport→/node,EventStore→/server). Tracks which v2 packages are used for automaticpackage.jsonupdates.symbolsMcpError→ProtocolError). SplitsErrorCodeintoProtocolErrorCode+SdkErrorCodebased on member usage. ConvertsRequestHandlerExtra→ServerContext/ClientContext. ReplacesSchemaInput<T>withStandardSchemaWithJSON.InferInput<T>.removed-apisschemaToJson,parseSchemaAsync, etc.), renamesIsomorphicHeaders→Headers, convertsStreamableHTTPError→SdkErrorwith constructor mapping warnings.mcpserver-apiMcpServermethod calls:.tool()→.registerTool(),.prompt()→.registerPrompt(),.resource()→.registerResource(). Restructures 2-4 positional args into(name, config, callback)form. Wraps raw object schemas withz.object().handlersserver.setRequestHandler(CallToolRequestSchema, ...)toserver.setRequestHandler('tools/call', ...)using the schema-to-method mapping table. Covers 15 request schemas and 8 notification schemas.schema-params.request(),.callTool(), and.send()calls where the second argument is a schema reference (ends withSchema).express-middlewarehostHeaderValidation({ allowedHosts: [...] })→hostHeaderValidation([...]).contextextracallback parameter toctx. Rewrites 13 property access paths from the flat v1 context to the nested v2 context structure. Warns on destructuring patterns that need manual review.mock-pathsvi.mock()/jest.mock()specifiers from v1 to v2 paths. Updatesimport()expressions in mock factories. Renames symbol references inside mock factory return objects.CLI Usage
Programmatic API
How Has This Been Tested?
package.jsonupdates..d.tsexclusion, unknown transform ID validation.Breaking Changes
None — this is a new package with no existing users.
Types of changes
Checklist
Additional context
Design Decisions
ts-morph over jscodeshift: ts-morph provides full TypeScript type information and a more ergonomic API for the precise symbol-level transforms this codemod requires (e.g., distinguishing
ErrorCode.RequestTimeoutfromErrorCode.InvalidRequestfor theProtocolErrorCode/SdkErrorCodesplit).Ordered transforms with per-file isolation: Transforms run in a declared order (imports first, mocks last). If a transform throws for a given file, the remaining transforms are skipped for that file (since the AST may be in a partially-mutated state), but processing continues for other files. An error diagnostic is emitted for the affected file.
Declarative mapping tables: All rename/remap rules live in dedicated mapping files (
importMap.ts,symbolMap.ts, etc.) rather than being scattered across transform logic. This makes the migration rules auditable and easy to extend.Context-aware import resolution: Shared v1 paths like
@modelcontextprotocol/sdk/types.jsare resolved to client or server packages based onpackage.jsondependency analysis, not just hardcoded defaults.Diagnostic-first approach for removals: When the codemod encounters removed APIs (WebSocket transport, server auth, Zod helpers), it doesn't silently drop them — it emits clear warning diagnostics with migration guidance so users know what needs manual attention.
Automatic
package.jsonupdates: As transforms rewrite imports, they track which v2 packages are targeted. After all source transforms complete, the runner removes@modelcontextprotocol/sdkfrompackage.jsonand adds exactly the v2 packages the code actually uses. Version specifiers are injected at build time from sibling package versions viascripts/generate-versions.ts. Private packages (@modelcontextprotocol/core) are filtered out. The updater preserves the original indentation and trailing newline, and respects dry-run mode.Import splitting with
symbolTargetOverrides: When symbols from a single v1 module map to different v2 packages (e.g.,StreamableHTTPServerTransport→@modelcontextprotocol/nodebutEventStore→@modelcontextprotocol/server), the import map supports per-symbol target overrides. The transform splits the import into separate declarations for each target package.